(圖/Entity Component System for Unity: Getting Started)
ECS架構,也就是Entity-Componet-System。由「實體」、「組件」、「系統」三個單字所組成的一種程式架構,有別於傳統物件導向OOP(Object Oriented Programming)的概念,ECS是建立在資料導向DOP(Data Oriented Programming)的概念上。這裡稍微簡單介紹一下關於OOP與DOP。
可以理解成以我們的視角來對一個概念去做抽象,定義成一個類(Class),包含屬性(Field)、方法(Method)。而我們在實作時需要有物件(Object),也就是Class的實例,而物件與物件間互相不影響,皆為獨立的。至於其他特性這裡就先不說明了。
而OOP在大型的遊戲設計中並非最佳的選擇。
而DOP則是以另外一種方式來思考,假設你有N個物件,每個物件有M種屬性,將個別的屬性用一個array來儲存,這麼設計的話會有什麼作用呢?
因此我們就來介紹一下ECS架構具體一點的邏輯。
實體(Entity)
是一種概念,在ECS架構裡沒有物件的概念,而是以實體稱乎,實體是組件的集合,而通常時體會以ID來進行表示。舉例來說,一個玩家可以是實體,而他會由位置、血量、模型等等組件所組成,而實體應該要能隨時新增、刪除組件。
組件(Componet)
組件則是一種資料的集合,資料會代表一種實體的特性,就像是「位置」、「血量」等等...由於實體可以新增、刪除組件,我們也能藉由組件來對實體做狀態的標記等等,例如玩家中毒時可以新增一個中毒的組件。
系統(System)
系統則是在迴圈中負責去對組件進行操作的概念。一個系統只有方法而沒有屬性,舉例來說,移動系統就是一個負責更新實體位置的系統,控制擁有位置組件與速度組件的實體集合,並為他們計算、更新位置完成移動。
Entt是一個C++函式庫,雖然我們擁有了ECS的概念,但自行去編寫或許還是不比原先的方法來的有效率。而Entt就是一種ECS的框架,而Mojang 的 Minecraft就是使用Entt來作為他的ECS系統的。
而筆者前幾天介紹的vcpkg也支持Entt。
而我們也來簡單看看官方範例(這裡的代碼示例)。
可以先看到在最上面,範例先建立了兩個struct,position(位置)和velocity(速度),而他們各有兩個儲存資料。
struct position {
float x;
float y;
};
struct velocity {
float dx;
float dy;
};
讓我們看到main函式中,範例先宣告了一個用於管理的註冊表。
entt::registry registry;
接著跑迴圈,每個迴圈中都建立一個實體,並將實體加入位置屬性,而偶數位的實體則多加入速度屬性。並執行update函數。
for(auto i = 0u; i < 10u; ++i) {
const auto entity = registry.create();
registry.emplace<position>(entity, i * 1.f, i * 1.f);
if(i % 2 == 0) { registry.emplace<velocity>(entity, i * .1f, i * .1f); }
}
讓我們來看update的部分,這裡介紹了四種用來遍歷組件的方法。
void update(entt::registry ®istry) {
auto view = registry.view<const position, velocity>();
// use a callback
view.each([](const auto &pos, auto &vel) { /* ... */ });
// use an extended callback
view.each([](const auto entity, const auto &pos, auto &vel) { /* ... */ });
// use a range-for
for(auto [entity, pos, vel]: view.each()) {
// ...
}
// use forward iterators and get only the components of interest
for(auto entity: view) {
auto &vel = view.get<velocity>(entity);
// ...
}
}
我們一個一個來拆解。首先先宣告了一個view,用來取得擁有position與velocity組件的實體。
auto view = registry.view<const position, velocity>();
第一種方法,可以透過each函式來遍歷。可以傳入一個lambda函式來控制每個實體在更新時要做的動作。
// use a callback
view.each([](const auto &pos, auto &vel) { /* ... */ });
而第二種與第一種是相同的,差別在於操作時可以額外的對實體進行其他操作。
// use an extended callback
view.each([](const auto entity, const auto &pos, auto &vel) { /* ... */ });
第三、四種則是使用Range-based for Statement來操作,透過structured binding來遍歷view.each(),或是可以直接遍歷view取得實體,並使用get<組件>(實體) 來取得實體組件的值。
// use a range-for
for(auto [entity, pos, vel]: view.each()) {
// ...
}
// use forward iterators and get only the components of interest
for(auto entity: view) {
auto &vel = view.get<velocity>(entity);
// ...
}
總體而言,ECS架構理論上性能上是與傳統OOP的方法有所提升,不過筆者也還並未實作過,這裡僅以科普的角度來介紹一下這個架構,若有誤還請包涵與指出!
題外話,今天晚上在打俄羅斯方塊的比賽。原本早上預計寫個幾篇,太晚睡醒加上花了一點時間暖身... 總而言之就先用科普文章來頂個一天...QAQ,(不幸的進了八強! 明天繼續比賽)